許多 CLI 工具(如 Claude Code)在執行前需先驗證使用者身分。在設計登入機制時,關鍵在於明文密碼是否有流經 CLI Process。
最直接的做法是在終端機輸入帳號密碼,由 CLI 轉送給 Auth Server 驗證:

這條路徑上,密碼會先進入 CLI Process 的記憶體,再由 CLI 送出。CLI 本身、它引用的套件,以及記錄 log 的程式碼,都有機會接觸到密碼。
OAuth 2.0 把輸入密碼的步驟移到瀏覽器登入頁,驗證成功後,Auth Server 只把 Access Token 交給 CLI:

使用者在瀏覽器完成密碼、MFA 或 SSO 驗證,並指定要授予 CLI 的權限範圍(scope)。密碼完全不離開瀏覽器與 Auth Server,CLI Process 從頭到尾只會拿到 Access Token。
這種做法讓服務端能控制 Access Token 的有效期限與權限範圍,並允許使用者隨時單獨撤銷某一個 CLI 的授權。即使 token 外流,也能獨立撤銷,不必更換帳號密碼或中斷其他已登入的應用。
以 GitHub CLI(gh)為例,執行 gh auth login 時,GitHub CLI 不會在終端機詢問密碼,而是顯示一次性代碼,並引導使用者開啟 GitHub 網頁授權:
$ gh auth login
! First copy your one-time code: 449E-A75B
Press Enter to open https://github.com/login/device in your browser...
接著在開啟的網頁上,輸入這個one-time code,接著完成登入流程。
瀏覽器完成授權後,CLI 取得 access token,後續的 gh repo view 或 gh pr create 便能帶著 token 呼叫 GitHub API。

了解 GitHub CLI 的登入體驗後,要在自己的 CLI 實作 OAuth 2.0 授權,主要依據執行環境選擇以下兩種標準流程之一:
127.0.0.1),瀏覽器完成授權後直接重導向(Redirect)將結果傳回 CLI。適用於具備本機瀏覽器的桌機與筆電,省去輪詢等待。兩者均在瀏覽器進行身分驗證,主要差異在於 Auth Server 如何將授權結果傳回 CLI。
對應的可執行 Go 範例放在 ../cli-sample/ 目錄:goAuthSample/ 負責 Device Flow,goAuthPKCE/ 則負責 Authorization Code + PKCE。
兩個專案各自包含本機 mock Auth Server,會將 authorization code、token 與使用者資料存在記憶體中。
Device Flow 適合 CLI 所在的機器無法開啟瀏覽器,或 CLI 不適合監聽 callback port 的情境。CLI 顯示網址與驗證碼後,使用者可以拿手機或另一台電腦開啟該網址:
請在瀏覽器開啟:
https://example.com/activate
並輸入代碼:ABCD-EFGH
等待授權中...
CLI 不需要知道瀏覽器在哪裡,只要按照 Auth Server 指定的 interval 輪詢 token endpoint。使用者完成授權後,下一次輪詢就會收到 token。
首先在goAuthSample執行go run server/main.go 啟動 Device Flow 的 Auth Server:
go run server/main.go
Server 監聽 127.0.0.1:8080,提供四個 endpoint:
POST /device/authorization:產生 device_code、user_code、有效期限與輪詢間隔。GET/POST /activate:讓使用者輸入驗證碼,進行登入與同意授權。POST /token:供 CLI 查詢授權狀態並交換 Token。GET /me:驗證 Bearer Token 並回傳使用者資料。在第二個終端機上執行 CLI:
go run client/main.go
CLI 呼叫 POST /device/authorization 後會顯示:
正在向 auth server 申請授權...
請在瀏覽器開啟:
http://127.0.0.1:8080/activate
並輸入代碼:ABCD-EFGH
等待授權中
由於 Mock Auth Server 僅監聽本機位址(127.0.0.1 / Loopback),因此測試時需在同一台電腦開啟 http://127.0.0.1:8080/activate 。真實環境下,Device Flow 的 verification URI 會指向遠端 Auth Server,使用者便能直接改用手機或其他裝置開啟並完成授權。
在網頁輸入 CLI 顯示的代碼:

再使用任測試帳號登入:
bob / bob456

瀏覽器完成授權後,CLI 的下一次輪詢會取得 access token,並用 Bearer token 呼叫 GET /me:
✓ 授權成功!
access_token : tok_...
token_type : Bearer
scope : read write
受保護 API 回應 (GET /me):
{"client_id":"my-cli-app","scope":"read write","username":"bob"}
執行期間的資料流如下:

在程式實作上,client/main.go 與 server/main.go 依據這套資料流分工:
requestDeviceAuth() 發送請求,Server 由 handleDeviceAuthorization() 產生驗證碼。handleActivate() 處理登入與授權(Client 此階段無須處置)。pollToken() 定期輪詢,Server 由 handleToken() 檢查狀態並發放 Token。callProtectedAPI() 攜帶 Bearer Token 請求,Server 由 handleMe() 驗證身分並回傳使用者資料。在發送與驗證請求時,Auth Server 會產生兩種用途不同的代碼:
device_code:由 CLI 內部保留,用於向 Auth Server 輪詢交換 Token。此為輪詢憑證,切勿寫入 Log 或顯示給使用者。user_code:顯示於終端機供使用者複製,並在瀏覽器驗證頁面輸入(格式通常簡短易讀,如 ABCD-EFGH)。Auth Server 在後端將這兩組代碼對應至同一筆授權狀態。當使用者於網頁輸入 user_code 並完成授權後,CLI 透過 device_code 輪詢即可順利取得 Access Token。
在使用者開啟瀏覽器登入的同時,CLI 無法預知使用者何時完成操作,因此必須定期向 Auth Server 發送 POST /token 詢問「使用者授權完了嗎?」。
Auth Server 在一開始傳回的 interval(例如 5 秒)規定了查詢頻率。CLI 在輪詢過程中,會依據 Server 回傳的狀態調整行為:
authorization_pending):使用者還在網頁輸入密碼或同意授權。這是正常的等待過程,CLI 依據 interval 間隔繼續下一輪查詢。slow_down):Server 提示 CLI 發送請求過快,CLI 必須自動拉長查詢間隔(如每次增加 5 秒),避免對 Server 造成負擔。access_denied):使用者在網頁點選取消授權,CLI 應立即結束輪詢並顯示授權失敗。expired_token):使用者超過有效時間未完成授權,CLI 應結束輪詢並提示使用者重新發起登入。Authorization Code + PKCE 適合在具備本機瀏覽器的個人電腦(如桌機或筆電)上執行。CLI 可以在 127.0.0.1 監聽隨機埠,開啟瀏覽器後等待重導向(Redirect)。使用者完成登入時,Auth Server 把短效的 authorization code 重導向傳回 callback,CLI 隨即拿 code 交換 Token,不需要定期輪詢。
先啟動 PKCE 的 Auth Server,在goAuthPKCE執行:
go run server/main.go
Server 監聽 127.0.0.1:8081,提供 /authorize、/token 與 /me。在第二個終端機執行 CLI:
go run client/main.go
CLI 先產生 verifier、challenge 與 state,再讓作業系統選擇可用的 callback port:
正在啟動本地授權回調伺服器...
回調位址:http://127.0.0.1:60108/callback
正在開啟瀏覽器授權頁面...
http://127.0.0.1:8081/authorize?client_id=my-cli-app&code_challenge=...
若瀏覽器未自動開啟,請手動複製上方連結。
等待瀏覽器授權中
使用前面提到的帳號登入。Auth Server 驗證帳密後產生 authorization code,並 redirect 到 CLI 的 callback。CLI 驗證 state,再把 code 與 verifier 送到 POST /token:
正在換取 access token...
✓ 授權成功!
access_token : tok_...
token_type : Bearer
scope : read write
受保護 API 回應 (GET /me):
{"client_id":"my-cli-app","scope":"read write","username":"admin"}
PKCE 流程的資料流如下:
程式碼依照這條資料流分工:
在程式實作上,client/main.go 與 server/main.go 依據這套資料流分工:
generateCodeVerifier()、generateCodeChallenge() 與 generateState()。startCallbackServer() 在 127.0.0.1:0 監聽隨機埠。authURL 並以 openBrowser() 開啟;Server 由 handleAuthorize() GET 處理並渲染頁面。handleAuthorize() POST 產生 code 並重導向;Client 在 callback Channel 等待接收。exchangeCode() 送出 code 與 verifier;Server 由 handleToken() 驗證後發放 Token。callProtectedAPI() 攜帶 Bearer Token 發送請求;Server 由 handleMe() 驗證身分並回傳資料。startCallbackServer() 使用 127.0.0.1:0 監聽,port 0 代表由作業系統分配可用 port。callback 收到 request 後先驗證 state,再把 authorization code 送回主流程:
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
ch := make(chan callbackResult, 1)
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
ch <- callbackResult{Code: r.URL.Query().Get("code")}
})
state 綁定「發出授權請求的 CLI Process」與「收到 redirect 的 callback」。若兩者不一致,CLI 拒絕這次 callback,避免其他網頁把自己的授權結果塞進目前的登入流程。
Server 收到 token request 時,還要確認 client_id、redirect_uri 與 SHA-256(code_verifier) 都和授權時保存的資料一致。handleToken() 在同一個 mutex critical section 內完成驗證、刪除 authorization code 與建立 token,避免兩個並行 request 重複兌換同一組 code。
兩個 client 範例取得 token 後會立刻呼叫 /me。正式的 mycli login 還要保存 token,後續命令才能直接使用:
mycli login
└─ 完成 OAuth flow,保存 token
mycli repo list
└─ 讀取 token,設定 Authorization: Bearer <token>
mycli logout
└─ 撤銷遠端 token,刪除本機 credential
系統 keychain 能把 credential 交給 macOS Keychain、Windows Credential Manager 或 Linux Secret Service 管理。若執行環境沒有 keychain,可以把 token 寫進使用者設定目錄,並把目錄權限設為 0700、檔案權限設為 0600:
func saveToken(data []byte) error {
configRoot, err := os.UserConfigDir()
if err != nil {
return err
}
dir := filepath.Join(configRoot, "mycli")
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.Chmod(dir, 0o700); err != nil {
return err
}
path := filepath.Join(dir, "token.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
access token 過期時,CLI 應使用 refresh token 取得新 token,或重新啟動登入流程。refresh token 的存放標準要和 access token 相同;logout 除了刪除本機資料,也要呼叫 Auth Server 的 revocation endpoint,讓已複製出去的 token 一併失效。
採用 OAuth 2.0 能避免使用者於終端機輸入明文密碼,將密碼驗證留在安全的 Auth Server 瀏覽器頁面,並讓 CLI 僅持有權限受限且可獨立撤銷的 Access Token。在實作上,開發者可依據 CLI 的執行環境與連線條件,選擇 Device Flow 或 Authorization Code + PKCE 兩種標準流程之一,兼顧安全性與使用者體驗。
make 內建函式、sync.Mutex 互斥鎖、陣列與切片轉型與帶容量 Channel如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:
make 內建函式與動態切片記憶體分配在 Go 語言中,make 是專門用來初始化內建型別(Slice、Map 與 Channel)的內建函式。與 new 回傳指標不同,make 會直接回傳已適當初始化並分配底層記憶體的實例:
// 使用 make 分配一個包含 32 個 byte 的切片
b := make([]byte, 32)
_, err := rand.Read(b) // 將亂數填充至以 make 分配的切片中
if err != nil {
return "", err
}
sync.Mutex 互斥鎖與 Map 併發存取保護Go 的 map 不是執行緒安全的(Not Thread-Safe)。若有多個 HTTP Handler Goroutine 同時讀寫 map,程式會觸發 concurrent map writes 致命錯誤並崩潰。使用 sync.Mutex 可以在存取前加鎖、存取後解鎖:
var mu sync.Mutex
var sessionMap = make(map[string]Session)
// 加鎖保護 map 的讀寫操作
mu.Lock()
sessionMap[state] = session
mu.Unlock()
sha256.Sum256 回傳值為 [32]byte(長度固定為 32 的陣列 Array)。在 Go 中,陣列 [32]byte 與切片 []byte 是不同的型別。要將固定陣列傳給接收 []byte 切片的函式,需使用 hash[:] 切片運算子轉型:
hash := sha256.Sum256([]byte(verifier)) // 回傳 [32]byte 陣列
// 使用 hash[:] 語法將 [32]byte 陣列切片化為 []byte 切片
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
若使用無容量通道(Unbuffered Channel make(chan string)),當接收端因為逾時(Timeout)結束等待離開後,發送端的背景 Goroutine 會永遠阻塞在 Channel 寫入,導致 Goroutine 洩漏。宣告容量為 1 的通道能確保即使沒有接收者,發送端也能順利寫入後結束:
// 容量為 1 的通道:寫入一筆資料時不會阻塞背景 Handler Goroutine
codeCh := make(chan string, 1)
// HTTP Callback 處理常式寫入資料後可立即返回,不會因逾時棄收而永遠卡住
codeCh <- authCode